本系列文章會在筆者的部落格繼續連載!Design System 101 感謝大家的閱讀!
本文同步上傳到筆者的個人部落格,裡面透過 Sandpack 直接編輯程式碼!
在前一篇文章中,我們透過遍歷 React Children 的方式找到每個子組件的型別,並且將其放入對應的 Slot 中,但這樣的方式有一個缺點,就是當組件的層級越深,就需要越多的遍歷,這樣的效能會變得越來越差。
而今天我們將會介紹如何透過 React Context API 來解決這個問題。
首先先建立一個 Context
const ButtonContext = React.createContext({
  slots: {
    icon: null,
    content: null,
  },
});
再來就是透過 ButtonContext.Provider 來包住子組件,並且可以透過 slots 來定義每個 Slot 的內容
const ButtonContextProvider = ({ children, slots }) => {
  return <ButtonContext.Provider value={slots}>{children}</ButtonContext.Provider>;
};
最後建立一個 useButtonContext 來取得 Context 的值
const useButtonContext = (props, slotName) => {
  const context = React.useContext(ButtonContext);
  return { ...(props || {}), ...(context?.[slotName] || {}) };
};
最後再建立 Button 與 Icon 組件時,加入 useButtonContext 來取得相對應 Slot 的內容
const ICONS = {
  play: '⏯️',
  thumbUp: '👍',
};
const Icon = (props: { type: string }) => {
  props = useButtonContext(props, 'icon');
  return (
    <span style={{ marginRight: '4px', display: 'inline-flex' }} {...props}>
      {ICONS[props.type]}
    </span>
  );
};
const Button = (props: { children: React.ReactNode }) => {
  props = useButtonContext(props, 'button');
  return (
    <button {...props}>
      <Icon type="play" />
      {props.children}
    </button>
  );
};
而這就是用 React Context API 建立 Slots 這個概念,也是目前 Adobe React-Spectrum Slot 的設計方式,透過 Context API 來定義 Slots 的內容,並且透過 useSlotProps 來取得相對應 Slot 的值。
接著來探讀 Adobe React-Spectrum Slot 是如何建立 Slots 組件,在這之前,我們不仿想一下如何讓上面的例子適用於不同的組件中,這時我們可能需要以下的 API
| Name | Description | Params | 
|---|---|---|
| SlotProvider | 建立 Slots 的 Context | slots | 
| useSlotProps | 使用對應 Slot 的內容 | props,defaultSlot | 
| mergeProps | 合併所有的 props | args | 
在正式進入實作之前,要先思考一下當開發者對同一個 slot 傳入重複的 props 我們要如何處理,例如以下的例子
<SlotProvider
  slots={{ icon: { type: 'play' }, className: 'mb-4', onClick={myClickLogic} }}
>
  <SlotProvider
    slots={{ icon: { type: 'pause' }, className: 'd-flex', onClick={defaultClickLogic} }}
  >
    <Button />
  </SlotProvider>
</SlotProvider>
這時後我們要保留最外層的 type (因為這是最後一個傳入的值),但是 className 則是要合併,最後 onClick 這種事件行函式則是要連續的呼叫,這時候我們就可以透過 mergeProps 來解決這個問題。
export const chain = (...fns) => {
  return (...args) => {
    fns.forEach((fn) => typeof fn === 'function' && fn?.(...args));
  };
};
export const mergeProps = (...args) => {
  const result = { ...args[0] };
  for (let i = 1; i < args.length; i++) {
    const props = args[i];
    for (const key in props) {
      const a = result[key];
      const b = props[key];
      if (
        typeof a === 'function' &&
        typeof b === 'function' &&
        key.startsWith('on') &&
        key.charCodeAt(2) <= 90 &&
        key.charCodeAt(2) >= 65
      ) {
        result[key] = chain(a, b);
        continue;
      }
      if (key === 'className') {
        result[key] = clsx(a, b);
        continue;
      }
      result[key] = b === undefined ? a : b;
    }
  }
  return result;
};
再來我們就可以實作 SlotProvider 了,因為 React Context 只會取最近的一層 Context,如果也要讓其他開發者放入 slot 參數都可以被取到,透過 useContext 先取得 parentSlots,
透過 reduce 將相同的 slot 組合起來, 並將 slots 與 parentSlots 透過 mergeProps 的方式進行合併,最後用 useMemo 將結果進行儲存,避免重複計算。
const SlotContext = React.createContext(null);
const SlotProvider = (props) => {
  const parentSlots = useContext(SlotContext) || {};
  const { slots = {}, children } = props;
  const value = useMemo(() => {
    return Object.keys(parentSlots)
      .concat(Object.keys(slots))
      .reduce(
        (acc, props) => ({
          ...acc,
          [props]: mergeProps(parentSlots[props] || {}, slots[props] || {}),
        }),
        {},
      );
  }, [parentSlots, slots]);
  return <SlotContext.Provider value={value}>{children}</SlotContext.Provider>;
};
useSlotProps 則相對簡單,我們只需要取得 slot 的值,並且回傳對應的 props 即可。
const useSlotProps = (props, defaultSlot) => {
  const slot = props.slot || defaultSlot;
  const context = useContext(SlotContext) || {};
  return mergeProps(props, mergeProps(slot ? context[slot] : {}, { id: props.id }));
};
最後再將上面的 API 應用到 Button 組件中,首先我們先定義 Button, Icon 組件
const ICONS = {
  play: '⏯️',
  thumbUp: '👍',
};
const Icon = (props) => {
  props = useSlotProps(props, 'icon');
  return (
    <span style={{ marginRight: '4px', display: 'inline-flex' }} {...props}>
      {ICONS[props.type]}
    </span>
  );
};
const Button = (props) => {
  props = useSlotProps(props, 'button');
  return (
    <button {...props}>
      <Icon type="play" />
      {props.children}
    </button>
  );
};
有了 SlotProvider 之後,就可以改 Button 或是 Icon 的參數
const App = () => {
  return (
    <SlotProvider
      slots={{
        button: {
          style: { display: 'flex', alignItems: 'center' },
        },
        icon: {
          style: { marginRight: '8px', display: 'inline-flex' },
          type: 'thumbUp',
        },
      }}
    >
      <Button>Play!!</Button>
    </SlotProvider>
  );
};
明天將介紹 controllState 與 unControllState!